Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit 22b4fcce096023ec706bab5449805e34a2912643


Parents : 5745d53
Author : Ivan <e46112d44649266d71fe2193e00a4710>
Signature : T66BB85Valid, signed by author
Date : 2026-07-26T07:06:45-05:00

feat: add demo mode and ALTCHA integration for better security for internet deployments and showcase functionality

Changes

51 files changed, 1201 insertions(+), 110 deletions(-)

M pnpm-lock.yaml +16 -1

Diff

diff --git a/docker-compose.demo.yml b/docker-compose.demo.yml
new file mode 100644
index 00000000..7adc71cd
--- /dev/null
+++ b/docker-compose.demo.yml
@@ -0,0 +1,63 @@
+# Coolify public demo stack (read-only mesh showcase, ALTCHA, session auth).
+# https://coolify.io/docs/knowledge-base/docker/compose
+#
+# Set MESHCHAT_ALTCHA_HMAC_KEY in Coolify before deploy (required below).
+# Showcase login password defaults to demo (override MESHCHAT_DEMO_AUTH_PASSWORD).
+#
+# Assign a domain in Coolify with container port 8000, for example:
+# https://meshchatx.example.com:8000
+# The :8000 suffix is the container port for the proxy, not the public URL port.
+#
+# Coolify terminates TLS at the proxy. Plain HTTP inside the container
+# (MESHCHAT_NO_HTTPS=1) avoids a self-signed HTTPS backend. Do not add host
+# ports unless you intend to bypass the proxy.
+
+services:
+ meshchatx:
+ image: ${MESHCHAT_IMAGE:-quad4io/meshchatx:latest}
+ restart: unless-stopped
+ init: true
+ user: "1000:1000"
+ security_opt:
+ - no-new-privileges:true
+ cap_drop:
+ - ALL
+ read_only: true
+ tmpfs:
+ - /tmp:noexec,nosuid,size=256m
+ - /home/meshchat:nosuid,size=64m
+ environment:
+ - SERVICE_URL_MESHCHATX_8000
+ - MESHCHAT_DEMO_MODE=${MESHCHAT_DEMO_MODE:-1}
+ - MESHCHAT_AUTH=${MESHCHAT_AUTH:-1}
+ - MESHCHAT_AUTH_BYPASS=${MESHCHAT_AUTH_BYPASS:-0}
+ - MESHCHAT_ALTCHA_ENABLED=${MESHCHAT_ALTCHA_ENABLED:-1}
+ - MESHCHAT_ALTCHA_HMAC_KEY=${MESHCHAT_ALTCHA_HMAC_KEY:?}
+ - MESHCHAT_DEMO_AUTH_PASSWORD=${MESHCHAT_DEMO_AUTH_PASSWORD:-demo}
+ - "MESHCHAT_AUTH_PAGE_HINT=${MESHCHAT_AUTH_PAGE_HINT:-Username: demo, Password: demo}"
+ - MESHCHAT_DISABLE_PLUGINS=${MESHCHAT_DISABLE_PLUGINS:-1}
+ - MESHCHAT_HOST=${MESHCHAT_HOST:-0.0.0.0}
+ - MESHCHAT_PORT=${MESHCHAT_PORT:-8000}
+ - MESHCHAT_NO_HTTPS=${MESHCHAT_NO_HTTPS:-1}
+ - MESHCHAT_HEADLESS=${MESHCHAT_HEADLESS:-1}
+ volumes:
+ - meshchatx-config:/config
+ healthcheck:
+ test:
+ [
+ "CMD",
+ "python",
+ "-c",
+ "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/v1/status')",
+ ]
+ interval: 30s
+ timeout: 5s
+ retries: 3
+ start_period: 90s
+ cpus: 2.0
+ mem_limit: 1g
+ mem_reservation: 256m
+ pids_limit: 512
+
+volumes:
+ meshchatx-config:

diff --git a/docs/en/identity-and-security.md b/docs/en/identity-and-security.md
index 30457f75..d96a1c6f 100644
--- a/docs/en/identity-and-security.md
+++ b/docs/en/identity-and-security.md
@@ -36,6 +36,16 @@ Access attempts are logged. Repeated failures can trigger lockout when auth is e
Reset a forgotten password with `--reset-password` or `MESHCHAT_RESET_PASSWORD=true`, then set a new password in the UI.
+### Demo mode and ALTCHA
+
+`MESHCHAT_DEMO_MODE=1` (or `--demo`) enables a public showcase profile: privacy mode on, plugins off, no outbound announces, and a default-deny HTTP mutation policy with mesh send blocked. Status reports `demo_mode: true`.
+
+When `MESHCHAT_ALTCHA_ENABLED=1`, login and setup require a valid [ALTCHA](https://altcha.org/docs/v2/widget-v3/) proof-of-work payload (widget v3, server challenges use `PBKDF2/SHA-256` by default). Set `MESHCHAT_ALTCHA_HMAC_KEY` to a long random secret on the server. Optional `MESHCHAT_ALTCHA_COST` tunes PoW difficulty. The widget loads from the bundled `altcha` npm package and fetches challenges from `/api/v1/auth/altcha/challenge`.
+
+`MESHCHAT_AUTH_PAGE_HINT` sets optional plain text on the login page (independent of demo mode). Demo Docker compose defaults to username and password hints for the showcase account.
+
+`MESHCHAT_AUTH_BYPASS=1` skips session auth for local testing only. Do not use it on internet-facing deployments.
+
## Transport security
- HTTPS and WSS are on by default.

diff --git a/docs/en/installation.md b/docs/en/installation.md
index 2cded43d..90115cdf 100644
--- a/docs/en/installation.md
+++ b/docs/en/installation.md
@@ -61,6 +61,17 @@ Default Compose maps `127.0.0.1:8000` on the host to port `8000` in the containe
To bind a host directory instead, mount it at `/config`. The container runs as UID 1000. The host directory must be writable by that user.
+### Public demo instance (Coolify)
+
+For a read-only mesh showcase on [Coolify](https://coolify.io/docs/knowledge-base/docker/compose), deploy [`docker-compose.demo.yml`](../../docker-compose.demo.yml). For a normal (non-demo) Coolify deployment, use [`docker-compose.coolify.yml`](../../docker-compose.coolify.yml).
+
+- `MESHCHAT_DEMO_MODE=1` blocks outbound mesh actions and almost all API mutations.
+- `MESHCHAT_AUTH=1` with default showcase password `demo` (`MESHCHAT_DEMO_AUTH_PASSWORD`).
+- Optional `MESHCHAT_AUTH_PAGE_HINT` shows custom text on the login page (for example `Username: demo` and `Password: demo`). Demo compose sets a default hint.
+- `MESHCHAT_ALTCHA_ENABLED=1` and a strong `MESHCHAT_ALTCHA_HMAC_KEY` (required in demo compose via `:?`). The UI uses ALTCHA widget v3 with `PBKDF2/SHA-256` challenges from `/api/v1/auth/altcha/challenge`.
+- Assign a domain with container port **8000**, for example `https://meshchatx.example.com:8000`.
+- Do not set `MESHCHAT_AUTH_BYPASS=1` on a public host.
+
## Python wheel
1. Download `reticulum_meshchatx-*-py3-none-any.whl` from [releases](https://github.com/Quad4-Software/MeshChatX/releases).

diff --git a/meshchatx.rsm b/meshchatx.rsm
index 1197aaa2..41ea8f62 100644
Binary files a/meshchatx.rsm and b/meshchatx.rsm differ

diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index e2f7cbb3..82de10f9 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -81,6 +81,12 @@ from meshchatx.src.backend.csrf import (
rotate_session_csrf_token,
validate_csrf_header,
)
+from meshchatx.src.backend.altcha_auth import altcha_enabled_from_env
+from meshchatx.src.backend.auth_page_hint import auth_page_hint_from_env
+from meshchatx.src.backend.demo_mode import (
+ auth_bypass_from_env,
+ demo_auth_password_from_env,
+)
from meshchatx.src.backend.database.access_attempts import (
LOGIN_PATH,
MAX_FAILED_BEFORE_LOCKOUT,
@@ -560,9 +566,14 @@ class ReticulumMeshChat:
plugins_enabled: bool = True,
defer_network_setup: bool = False,
headless: bool = False,
+ demo_mode: bool = False,
+ altcha_enabled: bool = False,
):
self.running = True
self.plugins_enabled = plugins_enabled
+ self.demo_mode = bool(demo_mode)
+ self.altcha_enabled = bool(altcha_enabled)
+ self.auth_page_hint = auth_page_hint_from_env()
self._memory_diag_enabled = memory_diag_enabled
self._mem_diag = None
self._headless = bool(headless)
@@ -1034,6 +1045,8 @@ class ReticulumMeshChat:
@property
def auth_enabled(self):
+ if auth_bypass_from_env():
+ return False
if self.config:
return self.config.auth_enabled.get()
return self.auth_enabled_initial
@@ -1682,6 +1695,11 @@ class ReticulumMeshChat:
self._sideband_telemetry_running = False
def _startup_status_payload(self) -> dict:
+ demo_fields = {
+ "demo_mode": self.demo_mode,
+ "altcha_enabled": self.altcha_enabled,
+ "auth_page_hint": self.auth_page_hint,
+ }
if self._startup_stage == "failed" or self._startup_error:
payload = {
"status": "failed",
@@ -1694,6 +1712,7 @@ class ReticulumMeshChat:
"https_enabled": self.use_https,
"is_loopback_bind": _is_loopback_bind_host(self.listen_host),
"plugins_enabled": self.plugins_enabled,
+ **demo_fields,
**self._landlock_status_dict(),
}
if self._startup_error:
@@ -1716,6 +1735,7 @@ class ReticulumMeshChat:
"https_enabled": self.use_https,
"is_loopback_bind": _is_loopback_bind_host(self.listen_host),
"plugins_enabled": self.plugins_enabled,
+ **demo_fields,
**self._landlock_status_dict(),
}
@@ -1831,6 +1851,7 @@ class ReticulumMeshChat:
)
if self._network_ready:
self._finish_deferred_startup_services()
+ self._apply_demo_mode_runtime()
return
# Initialize Reticulum if not already done
@@ -1881,6 +1902,27 @@ class ReticulumMeshChat:
if self._network_ready:
self._finish_deferred_startup_services()
+ self._apply_demo_mode_runtime()
+
+ def _apply_demo_mode_runtime(self) -> None:
+ if not self.demo_mode:
+ return
+ ctx = self.current_context
+ if not ctx or not ctx.config:
+ return
+ self.plugins_enabled = False
+ ctx.config.privacy_mode_enabled.set(True)
+ ctx.config.auto_announce_enabled.set(False)
+ if self.auth_enabled_initial:
+ ctx.config.auth_enabled.set(True)
+ if self.auth_enabled and ctx.config.auth_password_hash.get() is None:
+ password = demo_auth_password_from_env()
+ password_hash = bcrypt.hashpw(
+ password.encode("utf-8"),
+ bcrypt.gensalt(),
+ ).decode("utf-8")
+ ctx.config.auth_password_hash.set(password_hash)
+
def _finish_deferred_startup_services(self) -> None:
"""Start non-critical services after network_ready is published."""
context = self.current_context
@@ -5106,6 +5148,7 @@ class ReticulumMeshChat:
security_middleware,
csrf_middleware,
ip_allowlist_middleware,
+ demo_mode_middleware,
) = register_all_routes(routes, self)
return (
@@ -5114,6 +5157,7 @@ class ReticulumMeshChat:
security_middleware,
csrf_middleware,
ip_allowlist_middleware,
+ demo_mode_middleware,
)
def _encrypted_cookie_storage(self, use_https: bool) -> EncryptedCookieStorage:
@@ -5215,6 +5259,7 @@ class ReticulumMeshChat:
security_middleware,
csrf_middleware,
ip_allowlist_middleware,
+ demo_mode_middleware,
) = self._define_routes(routes)
ssl_context = None
@@ -5324,6 +5369,7 @@ class ReticulumMeshChat:
security_middleware,
csrf_middleware,
ip_allowlist_middleware,
+ demo_mode_middleware,
],
)
@@ -5521,6 +5567,8 @@ class ReticulumMeshChat:
# handle announcing
async def announce(self, context=None):
+ if self.demo_mode:
+ return
ctx = context or self.current_context
if not ctx:
return
@@ -10386,6 +10434,12 @@ def main():
default=env_bool("MESHCHAT_AUTH", False),
help="Enable basic authentication for the web interface. Can also be set via MESHCHAT_AUTH environment variable.",
)
+ parser.add_argument(
+ "--demo",
+ action="store_true",
+ default=env_bool("MESHCHAT_DEMO_MODE", False),
+ help="Public demo mode: read-only mesh and blocked API mutations. Can also be set via MESHCHAT_DEMO_MODE environment variable.",
+ )
parser.add_argument(
"--no-https",
action="store_true",
@@ -10648,6 +10702,16 @@ def main():
or args.restore_db
or args.restore_from_snapshot,
)
+ if auth_bypass_from_env():
+ print(
+ "WARNING: MESHCHAT_AUTH_BYPASS=1 disables web UI authentication",
+ file=sys.stderr,
+ flush=True,
+ )
+
+ demo_mode = bool(args.demo)
+ altcha_on = altcha_enabled_from_env()
+
reticulum_meshchat = ReticulumMeshChat(
identity,
args.storage_dir,
@@ -10663,9 +10727,11 @@ def main():
rns_loglevel=rns_log_cli,
migration_context=migration_context,
memory_diag_enabled=args.memory_diag,
- plugins_enabled=not args.disable_plugins,
+ plugins_enabled=(not args.disable_plugins) and not demo_mode,
defer_network_setup=not needs_immediate_network,
headless=bool(args.headless),
+ demo_mode=demo_mode,
+ altcha_enabled=altcha_on,
)
# store recovery on app for wiring with identity context

diff --git a/meshchatx/src/backend/altcha_auth.py b/meshchatx/src/backend/altcha_auth.py
new file mode 100644
index 00000000..7fd39ab6
--- /dev/null
+++ b/meshchatx/src/backend/altcha_auth.py
@@ -0,0 +1,95 @@
+# SPDX-License-Identifier: 0BSD
+
+"""ALTCHA proof-of-work verification for login and setup."""
+
+from __future__ import annotations
+
+import os
+import time
+from typing import Any
+
+import altcha
+from aiohttp import web
+
+from meshchatx.src.env_utils import env_bool
+
+ALTCHA_INVALID_CODE = "altcha_invalid"
+ALTCHA_ALGORITHM = "PBKDF2/SHA-256"
+ALTCHA_DEFAULT_COST = 2000
+
+
+def altcha_enabled_from_env() -> bool:
+ return env_bool("MESHCHAT_ALTCHA_ENABLED", False)
+
+
+def altcha_hmac_secret() -> str | None:
+ raw = os.environ.get("MESHCHAT_ALTCHA_HMAC_KEY", "").strip()
+ return raw or None
+
+
+def altcha_configured() -> bool:
+ return altcha_enabled_from_env() and bool(altcha_hmac_secret())
+
+
+def altcha_pow_cost() -> int:
+ raw = os.environ.get("MESHCHAT_ALTCHA_COST", "").strip()
+ if not raw:
+ return ALTCHA_DEFAULT_COST
+ try:
+ return max(100, int(raw))
+ except ValueError:
+ return ALTCHA_DEFAULT_COST
+
+
+def create_altcha_challenge_dict() -> dict[str, Any]:
+ secret = altcha_hmac_secret()
+ if not secret:
+ msg = "MESHCHAT_ALTCHA_HMAC_KEY is required when ALTCHA is enabled"
+ raise RuntimeError(msg)
+ expires_at = int(time.time()) + 300
+ challenge = altcha.create_challenge(
+ ALTCHA_ALGORITHM,
+ altcha_pow_cost(),
+ hmac_secret=secret,
+ expires_at=expires_at,
+ )
+ return challenge.to_dict()
+
+
+def verify_altcha_submission(payload: Any) -> tuple[bool, str | None]:
+ secret = altcha_hmac_secret()
+ if not secret:
+ return False, "altcha_not_configured"
+ if payload is None:
+ return False, ALTCHA_INVALID_CODE
+ if isinstance(payload, dict):
+ import json
+
+ payload = json.dumps(payload)
+ if not isinstance(payload, str) or not payload.strip():
+ return False, ALTCHA_INVALID_CODE
+ try:
+ result = altcha.verify_solution(payload.strip(), secret)
+ except Exception:
+ return False, ALTCHA_INVALID_CODE
+ if not result.verified:
+ err = result.error or ALTCHA_INVALID_CODE
+ return False, err
+ return True, None
+
+
+def altcha_error_response(code: str) -> web.Response:
+ return web.json_response(
+ {"error": "ALTCHA verification failed", "code": code},
+ status=400,
+ )
+
+
+async def require_altcha_payload(request, data: dict) -> web.Response | None:
+ if not altcha_enabled_from_env():
+ return None
+ payload = data.get("altcha")
+ ok, code = verify_altcha_submission(payload)
+ if not ok:
+ return altcha_error_response(code or ALTCHA_INVALID_CODE)
+ return None

diff --git a/meshchatx/src/backend/auth_page_hint.py b/meshchatx/src/backend/auth_page_hint.py
new file mode 100644
index 00000000..3bb63a12
--- /dev/null
+++ b/meshchatx/src/backend/auth_page_hint.py
@@ -0,0 +1,12 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Optional login page hint text from environment."""
+
+from __future__ import annotations
+
+import os
+
+
+def auth_page_hint_from_env() -> str | None:
+ raw = os.environ.get("MESHCHAT_AUTH_PAGE_HINT", "").strip()
+ return raw or None

diff --git a/meshchatx/src/backend/demo_mode.py b/meshchatx/src/backend/demo_mode.py
new file mode 100644
index 00000000..9605a6d1
--- /dev/null
+++ b/meshchatx/src/backend/demo_mode.py
@@ -0,0 +1,100 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Public demo mode: read-only mesh and default-deny HTTP mutations."""
+
+from __future__ import annotations
+
+import os
+from typing import TYPE_CHECKING
+
+from aiohttp import web
+
+from meshchatx.src.env_utils import env_bool
+
+if TYPE_CHECKING:
+ from meshchatx.meshchat import ReticulumMeshChat
+
+DEMO_READONLY_CODE = "demo_readonly"
+
+DEMO_HTTP_MUTATION_ALLOWLIST: frozenset[str] = frozenset(
+ {
+ "/api/v1/auth/login",
+ "/api/v1/auth/logout",
+ "/api/v1/auth/setup",
+ "/api/v1/app/tutorial/seen",
+ "/api/v1/app/changelog/seen",
+ },
+)
+
+
+def demo_mode_from_env() -> bool:
+ return env_bool("MESHCHAT_DEMO_MODE", False)
+
+
+def auth_bypass_from_env() -> bool:
+ return env_bool("MESHCHAT_AUTH_BYPASS", False)
+
+
+def demo_auth_password_from_env() -> str:
+ return os.environ.get("MESHCHAT_DEMO_AUTH_PASSWORD", "demo")
+
+
+def normalize_api_path(path: str) -> str:
+ if not path.startswith("/api/"):
+ return path
+ parts = [p for p in path.split("/") if p]
+ return "/" + "/".join(parts)
+
+
+def demo_mode_active(app: ReticulumMeshChat) -> bool:
+ return bool(getattr(app, "demo_mode", False))
+
+
+def demo_mode_blocks_ws_type(app: ReticulumMeshChat, msg_type: str) -> bool:
+ if not demo_mode_active(app):
+ return False
+ from meshchatx.src.backend.websocket_config_guard import WEBSOCKET_MUTATOR_TYPES
+
+ return msg_type in WEBSOCKET_MUTATOR_TYPES
+
+
+def demo_mode_block_response(app: ReticulumMeshChat) -> web.Response | None:
+ if not demo_mode_active(app):
+ return None
+ return web.json_response(
+ {
+ "error": "Demo mode is read-only",
+ "code": DEMO_READONLY_CODE,
+ },
+ status=403,
+ )
+
+
+def demo_http_mutation_allowed(method: str, path: str) -> bool:
+ if method.upper() in ("GET", "HEAD", "OPTIONS"):
+ return True
+ if method.upper() not in ("POST", "PUT", "PATCH", "DELETE"):
+ return True
+ normalized = normalize_api_path(path)
+ if not normalized.startswith("/api/v1/"):
+ return True
+ return normalized in DEMO_HTTP_MUTATION_ALLOWLIST
+
+
+def create_demo_mode_middleware(app: ReticulumMeshChat):
+ @web.middleware
+ async def demo_mode_middleware(request, handler):
+ if demo_mode_active(app) and not demo_http_mutation_allowed(
+ request.method,
+ request.path,
+ ):
+ return web.json_response(
+ {
+ "error": "Demo mode is read-only",
+ "code": DEMO_READONLY_CODE,
+ },
+ status=403,
+ )
+ return await handler(request)
+
+ return demo_mode_middleware

diff --git a/meshchatx/src/backend/http/middleware.py b/meshchatx/src/backend/http/middleware.py
index ec2c75e7..2d49bffc 100644
--- a/meshchatx/src/backend/http/middleware.py
+++ b/meshchatx/src/backend/http/middleware.py
@@ -94,7 +94,11 @@ def create_auth_middleware(app):
# Allow CSRF bootstrap and auth status while the network stack starts so the
# Vue shell can load and show an in-app waiting state.
- if path in ("/api/v1/auth/csrf", "/api/v1/auth/status"):
+ if path in (
+ "/api/v1/auth/csrf",
+ "/api/v1/auth/status",
+ "/api/v1/auth/altcha/challenge",
+ ):
return await handler(request)
# Serve the web UI shell and static files while an identity context is still
@@ -149,6 +153,7 @@ def create_auth_middleware(app):
"/api/v1/auth/login",
"/api/v1/auth/status",
"/api/v1/auth/logout",
+ "/api/v1/auth/altcha/challenge",
"/manifest.json",
"/service-worker.js",
]

diff --git a/meshchatx/src/backend/http/register.py b/meshchatx/src/backend/http/register.py
index 03ce8ff1..28370739 100644
--- a/meshchatx/src/backend/http/register.py
+++ b/meshchatx/src/backend/http/register.py
@@ -4,6 +4,7 @@
from __future__ import annotations
+from meshchatx.src.backend.demo_mode import create_demo_mode_middleware
from meshchatx.src.backend.http.middleware import (
create_auth_middleware,
create_csrf_middleware,
@@ -28,4 +29,5 @@ def register_all_routes(routes, app):
create_security_middleware(app),
create_csrf_middleware(app),
create_ip_allowlist_middleware(app),
+ create_demo_mode_middleware(app),
)

diff --git a/meshchatx/src/backend/http/routes/auth.py b/meshchatx/src/backend/http/routes/auth.py
index e784a271..c29458cb 100644
--- a/meshchatx/src/backend/http/routes/auth.py
+++ b/meshchatx/src/backend/http/routes/auth.py
@@ -134,6 +134,18 @@ from meshchatx.src.backend.http.meshchat_names import ( # noqa: F401
def register_auth_routes(routes, app):
+ @routes.get("/api/v1/auth/altcha/challenge")
+ async def auth_altcha_challenge(request):
+ if not app.altcha_enabled:
+ return web.json_response({"error": "ALTCHA is not enabled"}, status=404)
+ try:
+ from meshchatx.src.backend.altcha_auth import create_altcha_challenge_dict
+
+ challenge = create_altcha_challenge_dict()
+ except RuntimeError as exc:
+ return web.json_response({"error": str(exc)}, status=503)
+ return web.json_response(challenge)
+
@routes.get("/api/v1/server/security")
async def server_security_get(request):
settings = load_app_security_settings(app.storage_dir)
@@ -208,6 +220,9 @@ def register_auth_routes(routes, app):
"network_ready": False,
"status": "starting",
"stage": app._startup_stage,
+ "demo_mode": app.demo_mode,
+ "altcha_enabled": app.altcha_enabled,
+ "auth_page_hint": app.auth_page_hint,
},
)
try:
@@ -226,6 +241,9 @@ def register_auth_routes(routes, app):
"password_set": app.config.auth_password_hash.get() is not None,
"authenticated": actually_authenticated,
"network_ready": True,
+ "demo_mode": app.demo_mode,
+ "altcha_enabled": app.altcha_enabled,
+ "auth_page_hint": app.auth_page_hint,
},
)
except Exception as e:
@@ -242,6 +260,9 @@ def register_auth_routes(routes, app):
"network_ready": bool(
app.current_context and app.current_context.running,
),
+ "demo_mode": app.demo_mode,
+ "altcha_enabled": app.altcha_enabled,
+ "auth_page_hint": app.auth_page_hint,
"error": str(e),
},
)
@@ -298,6 +319,11 @@ def register_auth_routes(routes, app):
{"error": "Invalid request body"},
status=400,
)
+ from meshchatx.src.backend.altcha_auth import require_altcha_payload
+
+ altcha_blocked = await require_altcha_payload(request, data)
+ if altcha_blocked is not None:
+ return altcha_blocked
password = data.get("password")
if not password or len(password) < 8:
@@ -380,6 +406,11 @@ def register_auth_routes(routes, app):
{"error": "Invalid request body"},
status=400,
)
+ from meshchatx.src.backend.altcha_auth import require_altcha_payload
+
+ altcha_blocked = await require_altcha_payload(request, data)
+ if altcha_blocked is not None:
+ return altcha_blocked
password = data.get("password")
password_hash = app.config.auth_password_hash.get()

diff --git a/meshchatx/src/backend/http/routes/lxmf.py b/meshchatx/src/backend/http/routes/lxmf.py
index 8996bff7..caa83c41 100644
--- a/meshchatx/src/backend/http/routes/lxmf.py
+++ b/meshchatx/src/backend/http/routes/lxmf.py
@@ -532,6 +532,11 @@ def register_lxmf_routes(routes, app):
# send lxmf message
@routes.post("/api/v1/lxmf-messages/send")
async def lxmf_messages_send(request):
+ from meshchatx.src.backend.demo_mode import demo_mode_block_response
+
+ blocked = demo_mode_block_response(app)
+ if blocked is not None:
+ return blocked
# get request body as json
data = await request.json()

diff --git a/meshchatx/src/backend/http/ws/dispatch.py b/meshchatx/src/backend/http/ws/dispatch.py
index ec8c85f4..f7e48d67 100644
--- a/meshchatx/src/backend/http/ws/dispatch.py
+++ b/meshchatx/src/backend/http/ws/dispatch.py
@@ -4,6 +4,7 @@
from __future__ import annotations
from meshchatx.src.backend.http.live_names import inject_meshchat_names
+from meshchatx.src.backend.demo_mode import demo_mode_blocks_ws_type
from meshchatx.src.backend.http.meshchat_names import ( # noqa: F401
AsyncUtils,
json,
@@ -54,6 +55,21 @@ async def dispatch_websocket_data(app, client, data):
if not _type:
return
+ if demo_mode_blocks_ws_type(app, _type):
+ logger.warning("Rejected WebSocket mutator in demo mode: %s", _type)
+ AsyncUtils.run_async(
+ client.send_str(
+ json.dumps(
+ {
+ "type": "error",
+ "message": "Demo mode is read-only",
+ "code": "demo_readonly",
+ },
+ ),
+ ),
+ )
+ return
+
if websocket_type_requires_auth(_type):
if not await app._websocket_session_authorized(client):
logger.warning("Rejected unauthorized WebSocket mutator: %s", _type)

diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue
index 41ddfed4..fce443a3 100644
--- a/meshchatx/src/frontend/components/App.vue
+++ b/meshchatx/src/frontend/components/App.vue
@@ -9,6 +9,8 @@
<AppShellBanners
:show-emergency="Boolean(appInfo?.emergency)"
:emergency-label="$t('app.emergency_mode_active')"
+ :show-demo="GlobalState.demoMode"
+ :demo-label="$t('app.demo_mode_active')"
:show-ws-disconnected="showWsDisconnectedBanner"
:ws-disconnected-label="backendOfflineBannerLabel"
:show-backend-recovery-actions="showBackendRecoveryActions"
@@ -676,6 +678,7 @@ export default {
const vuetifyTheme = useTheme();
return {
vuetifyTheme,
+ GlobalState,
};
},
data() {

diff --git a/meshchatx/src/frontend/components/auth/AuthPage.vue b/meshchatx/src/frontend/components/auth/AuthPage.vue
index 4930765a..75a7bf4e 100644
--- a/meshchatx/src/frontend/components/auth/AuthPage.vue
+++ b/meshchatx/src/frontend/components/auth/AuthPage.vue
@@ -1,90 +1,116 @@
<!-- SPDX-License-Identifier: 0BSD -->
<template>
- <div class="h-dvh min-h-0 w-full flex items-center justify-center bg-slate-50 dark:bg-zinc-950">
- <div class="w-full max-w-md p-8">
- <div
- class="bg-white dark:bg-zinc-900 rounded-2xl shadow-lg border border-gray-200 dark:border-zinc-800 p-8"
- >
- <div class="text-center mb-8">
- <div
- class="w-16 h-16 mx-auto mb-4 rounded-2xl overflow-hidden bg-white/70 dark:bg-white/10 border border-gray-200 dark:border-zinc-700 shadow-inner flex items-center justify-center"
- >
- <img class="w-16 h-16 object-contain p-2" :src="logoUrl" />
- </div>
- <h1 class="text-2xl font-bold text-gray-900 dark:text-zinc-100 mb-2">
- {{ isSetup ? "Initial Setup" : "Authentication Required" }}
- </h1>
- <p class="text-sm text-gray-600 dark:text-zinc-400">
- {{
- isSetup
- ? "Set an admin password to secure your MeshChatX instance"
- : "Please enter your password to continue"
- }}
- </p>
- </div>
+ <div class="h-dvh min-h-0 w-full flex flex-col bg-slate-50 dark:bg-zinc-950">
+ <div
+ v-if="demoMode"
+ class="relative z-100 shrink-0 bg-amber-600 text-white px-4 py-2 text-center text-sm font-medium shadow-md border-b border-amber-700/80"
+ role="status"
+ >
+ {{ $t("app.demo_mode_active") }}
+ </div>
- <form class="space-y-6" @submit.prevent="handleSubmit">
- <div>
- <label for="password" class="block text-sm font-medium text-gray-700 dark:text-zinc-300 mb-2">
- Password
- </label>
- <input
- id="password"
- v-model="password"
- type="password"
- required
- minlength="8"
- class="w-full px-4 py-2 border border-gray-300 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-800 text-gray-900 dark:text-zinc-100 focus:outline-hidden focus:ring-2 focus:ring-blue-500 focus:border-transparent"
- placeholder="Enter password"
- autocomplete="current-password"
- />
- <p v-if="isSetup" class="mt-2 text-xs text-gray-500 dark:text-zinc-500">
- Password must be at least 8 characters long
+ <div class="flex-1 min-h-0 flex items-center justify-center">
+ <div class="w-full max-w-md p-8">
+ <div
+ class="bg-white dark:bg-zinc-900 rounded-2xl shadow-lg border border-gray-200 dark:border-zinc-800 p-8"
+ >
+ <div class="text-center mb-8">
+ <div
+ class="w-16 h-16 mx-auto mb-4 rounded-2xl overflow-hidden bg-white/70 dark:bg-white/10 border border-gray-200 dark:border-zinc-700 shadow-inner flex items-center justify-center"
+ >
+ <img class="w-16 h-16 object-contain p-2" :src="logoUrl" alt="" />
+ </div>
+ <h1 class="text-2xl font-bold text-gray-900 dark:text-zinc-100 mb-2">
+ {{ isSetup ? $t("auth.setup_title") : $t("auth.login_title") }}
+ </h1>
+ <p class="text-sm text-gray-600 dark:text-zinc-400">
+ {{ isSetup ? $t("auth.setup_subtitle") : $t("auth.login_subtitle") }}
</p>
- </div>
-
- <div v-if="isSetup">
- <label
- for="confirmPassword"
- class="block text-sm font-medium text-gray-700 dark:text-zinc-300 mb-2"
+ <p
+ v-if="authPageHint"
+ class="mt-3 text-xs text-gray-600 dark:text-zinc-400 whitespace-pre-line"
>
- Confirm Password
- </label>
- <input
- id="confirmPassword"
- v-model="confirmPassword"
- type="password"
- required
- minlength="8"
- class="w-full px-4 py-2 border border-gray-300 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-800 text-gray-900 dark:text-zinc-100 focus:outline-hidden focus:ring-2 focus:ring-blue-500 focus:border-transparent"
- placeholder="Confirm password"
- autocomplete="new-password"
- />
+ {{ authPageHint }}
+ </p>
</div>
- <div
- v-if="error"
- class="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg"
- >
- <p class="text-sm text-red-800 dark:text-red-200">{{ error }}</p>
- </div>
+ <form class="space-y-6" @submit.prevent="handleSubmit">
+ <div>
+ <label
+ for="password"
+ class="block text-sm font-medium text-gray-700 dark:text-zinc-300 mb-2"
+ >
+ {{ $t("auth.password_label") }}
+ </label>
+ <input
+ id="password"
+ v-model="password"
+ type="password"
+ required
+ :minlength="isSetup ? 8 : 1"
+ class="w-full px-4 py-2 border border-gray-300 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-800 text-gray-900 dark:text-zinc-100 focus:outline-hidden focus:ring-2 focus:ring-blue-500 focus:border-transparent"
+ :placeholder="$t('auth.password_placeholder')"
+ autocomplete="current-password"
+ />
+ <p v-if="isSetup" class="mt-2 text-xs text-gray-500 dark:text-zinc-500">
+ {{ $t("auth.password_min_length") }}
+ </p>
+ </div>
+
+ <div v-if="isSetup">
+ <label
+ for="confirmPassword"
+ class="block text-sm font-medium text-gray-700 dark:text-zinc-300 mb-2"
+ >
+ {{ $t("auth.confirm_password_label") }}
+ </label>
+ <input
+ id="confirmPassword"
+ v-model="confirmPassword"
+ type="password"
+ required
+ minlength="8"
+ class="w-full px-4 py-2 border border-gray-300 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-800 text-gray-900 dark:text-zinc-100 focus:outline-hidden focus:ring-2 focus:ring-blue-500 focus:border-transparent"
+ :placeholder="$t('auth.confirm_password_placeholder')"
+ autocomplete="new-password"
+ />
+ </div>
+
+ <div v-if="altchaEnabled" class="min-h-[52px]">
+ <altcha-widget
+ ref="altchaWidget"
+ :challenge="altchaChallengeUrl"
+ auto="onsubmit"
+ name="altcha"
+ ></altcha-widget>
+ </div>
- <button
- type="submit"
- :disabled="isLoading || (isSetup && password !== confirmPassword)"
- class="w-full py-2.5 px-4 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white font-semibold rounded-lg transition-colors"
- >
- <span v-if="isLoading">Processing...</span>
- <span v-else>{{ isSetup ? "Set Password" : "Login" }}</span>
- </button>
- </form>
+ <div
+ v-if="error"
+ class="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg"
+ >
+ <p class="text-sm text-red-800 dark:text-red-200">{{ error }}</p>
+ </div>
+
+ <button
+ type="submit"
+ :disabled="isLoading || (isSetup && password !== confirmPassword)"
+ class="w-full py-2.5 px-4 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white font-semibold rounded-lg transition-colors"
+ >
+ <span v-if="isLoading">{{ $t("auth.processing") }}</span>
+ <span v-else>{{ isSetup ? $t("auth.set_password") : $t("auth.login") }}</span>
+ </button>
+ </form>
+ </div>
</div>
</div>
</div>
</template>
<script>
+import "altcha";
+
import logoUrl from "../../assets/images/logo.png";
export default {
@@ -97,6 +123,10 @@ export default {
error: "",
isLoading: false,
isSetup: false,
+ altchaEnabled: false,
+ demoMode: false,
+ authPageHint: "",
+ altchaChallengeUrl: "/api/v1/auth/altcha/challenge",
};
},
async mounted() {
@@ -119,22 +149,39 @@ export default {
}
this.isSetup = !status.password_set;
+ this.altchaEnabled = status.altcha_enabled === true;
+ this.demoMode = status.demo_mode === true;
+ const hint = status.auth_page_hint;
+ this.authPageHint = typeof hint === "string" ? hint : "";
} catch (e) {
console.error("Failed to check auth status:", e);
- this.error = "Failed to check authentication status";
+ this.error = this.$t("auth.status_check_failed");
}
},
+ readAltchaPayload() {
+ const widget = this.$refs.altchaWidget;
+ if (!widget) {
+ return null;
+ }
+ if (typeof widget.getPayload === "function") {
+ return widget.getPayload();
+ }
+ if (widget.value) {
+ return widget.value;
+ }
+ return null;
+ },
async handleSubmit() {
this.error = "";
if (this.isSetup) {
if (this.password !== this.confirmPassword) {
- this.error = "Passwords do not match";
+ this.error = this.$t("auth.passwords_mismatch");
return;
}
if (this.password.length < 8) {
- this.error = "Password must be at least 8 characters long";
+ this.error = this.$t("auth.password_min_length");
return;
}
}
@@ -143,13 +190,21 @@ export default {
try {
const endpoint = this.isSetup ? "/api/v1/auth/setup" : "/api/v1/auth/login";
- await window.api.post(endpoint, {
- password: this.password,
- });
+ const body = { password: this.password };
+ if (this.altchaEnabled) {
+ const altchaPayload = this.readAltchaPayload();
+ if (!altchaPayload) {
+ this.error = this.$t("auth.altcha_required");
+ this.isLoading = false;
+ return;
+ }
+ body.altcha = altchaPayload;
+ }
+ await window.api.post(endpoint, body);
window.location.reload();
} catch (e) {
- this.error = e.response?.data?.error || "Authentication failed";
+ this.error = e.response?.data?.error || this.$t("auth.failed");
this.password = "";
this.confirmPassword = "";
} finally {

diff --git a/meshchatx/src/frontend/components/layout/AppShellBanners.vue b/meshchatx/src/frontend/components/layout/AppShellBanners.vue
index f7390c83..70545443 100644
--- a/meshchatx/src/frontend/components/layout/AppShellBanners.vue
+++ b/meshchatx/src/frontend/components/layout/AppShellBanners.vue
@@ -12,6 +12,14 @@
</div>
</div>
+ <div
+ v-if="showDemo"
+ class="relative z-100 bg-amber-600 text-white px-4 py-2 text-center text-sm font-medium shadow-md border-b border-amber-700/80"
+ role="status"
+ >
+ {{ demoLabel }}
+ </div>
+
<div
v-if="showWsDisconnected"
class="relative z-100 bg-red-700 text-white px-4 py-3 text-center text-sm font-medium shadow-md border-b border-red-800/80"
@@ -103,6 +111,14 @@ export default {
type: String,
default: "",
},
+ showDemo: {
+ type: Boolean,
+ default: false,
+ },
+ demoLabel: {
+ type: String,
+ default: "",
+ },
showWsDisconnected: {
type: Boolean,
default: false,

diff --git a/meshchatx/src/frontend/js/GlobalState.js b/meshchatx/src/frontend/js/GlobalState.js
index bab283de..01a5414c 100644
--- a/meshchatx/src/frontend/js/GlobalState.js
+++ b/meshchatx/src/frontend/js/GlobalState.js
@@ -20,6 +20,7 @@ const globalState = reactive({
networkDegradedError: null,
networkStarting: false,
networkReady: true,
+ demoMode: false,
config: {
show_unknown_contact_banner: true,
banished_effect_enabled: true,

diff --git a/meshchatx/src/frontend/js/demoMode.js b/meshchatx/src/frontend/js/demoMode.js
new file mode 100644
index 00000000..bb0a0cee
--- /dev/null
+++ b/meshchatx/src/frontend/js/demoMode.js
@@ -0,0 +1,9 @@
+// SPDX-License-Identifier: 0BSD
+
+export function isDemoReadonly(status) {
+ return Boolean(status && status.demo_mode === true);
+}
+
+export function isAltchaEnabled(status) {
+ return Boolean(status && status.altcha_enabled === true);
+}

diff --git a/meshchatx/src/frontend/locales/de.json b/meshchatx/src/frontend/locales/de.json
index 95229ebb..1c763be7 100644
--- a/meshchatx/src/frontend/locales/de.json
+++ b/meshchatx/src/frontend/locales/de.json
@@ -343,6 +343,7 @@
"loading_overlay_title": "Profil wird gewechselt",
"loading_overlay_subtitle": "Chats und Einstellungen werden geladen. Das dauert meist nur wenige Sekunden.",
"emergency_mode_active": "Notfallmodus aktiv - In-Memory-Datenbank und eingeschränkte Dienste werden verwendet.",
+ "demo_mode_active": "Demo-Modus: Schreibgeschützte Mesh-Demo. Senden und Einstellungsänderungen sind deaktiviert.",
"backend_disconnected": "Verbindung zum Backend getrennt",
"backend_process_stopped": "Reticulum-Backend gestoppt",
"backend_reconnected": "Wieder mit dem Backend verbunden",
@@ -3937,6 +3938,24 @@
"failed_load_identities": "Failed to load management identities",
"failed_create_identity": "Failed to create management identity"
},
+ "auth": {
+ "setup_title": "Ersteinrichtung",
+ "login_title": "Anmeldung erforderlich",
+ "setup_subtitle": "Legen Sie ein Admin-Passwort fest, um Ihre MeshChatX-Instanz zu schützen",
+ "login_subtitle": "Bitte geben Sie Ihr Passwort ein, um fortzufahren",
+ "password_label": "Passwort",
+ "password_placeholder": "Passwort eingeben",
+ "password_min_length": "Das Passwort muss mindestens 8 Zeichen lang sein",
+ "confirm_password_label": "Passwort bestätigen",
+ "confirm_password_placeholder": "Passwort bestätigen",
+ "processing": "Wird verarbeitet...",
+ "set_password": "Passwort festlegen",
+ "login": "Anmelden",
+ "passwords_mismatch": "Passwörter stimmen nicht überein",
+ "altcha_required": "Schließen Sie zuerst die Verifizierungsaufgabe ab",
+ "status_check_failed": "Authentifizierungsstatus konnte nicht geprüft werden",
+ "failed": "Authentifizierung fehlgeschlagen"
+ },
"post_install": {
"windows_screen_security_title": "Windows-Bildschirmsicherheit aktivieren?",
"windows_screen_security_desc": "MeshChatX kann den Windows-Inhaltsschutz nutzen (dasselbe DRM-ähnliche Display-Flag wie Medien-Apps), damit Screenshots, Recorder und Windows Recall das App-Fenster nicht erfassen. Später änderbar unter Einstellungen -> Datenschutz.",

diff --git a/meshchatx/src/frontend/locales/en.json b/meshchatx/src/frontend/locales/en.json
index 4335a3f8..6f82c109 100644
--- a/meshchatx/src/frontend/locales/en.json
+++ b/meshchatx/src/frontend/locales/en.json
@@ -432,6 +432,7 @@
"loading_overlay_title": "Switching profile",
"loading_overlay_subtitle": "Loading your chats and settings. This usually takes a few seconds.",
"emergency_mode_active": "Emergency Mode Active - Using in-memory database and restricted services.",
+ "demo_mode_active": "Demo mode: read-only mesh showcase. Sending and settings changes are disabled.",
"backend_disconnected": "Disconnected from backend",
"backend_process_stopped": "Reticulum backend stopped",
"backend_reconnected": "Reconnected to backend",
@@ -4034,6 +4035,24 @@
"action_changelog": "Changelog",
"action_changelog_desc": "Recent changes"
},
+ "auth": {
+ "setup_title": "Initial Setup",
+ "login_title": "Authentication Required",
+ "setup_subtitle": "Set an admin password to secure your MeshChatX instance",
+ "login_subtitle": "Please enter your password to continue",
+ "password_label": "Password",
+ "password_placeholder": "Enter password",
+ "password_min_length": "Password must be at least 8 characters long",
+ "confirm_password_label": "Confirm Password",
+ "confirm_password_placeholder": "Confirm password",
+ "processing": "Processing...",
+ "set_password": "Set Password",
+ "login": "Login",
+ "passwords_mismatch": "Passwords do not match",
+ "altcha_required": "Complete the verification challenge first",
+ "status_check_failed": "Failed to check authentication status",
+ "failed": "Authentication failed"
+ },
"post_install": {
"windows_screen_security_title": "Enable Windows screen security?",
"windows_screen_security_desc": "MeshChatX can use Windows content protection (the same DRM-style display flag media apps use) so screenshots, recorders, and Windows Recall cannot capture the app window. You can change this later under Settings -> Privacy.",

diff --git a/meshchatx/src/frontend/locales/es.json b/meshchatx/src/frontend/locales/es.json
index f96f6a86..a12aced0 100644
--- a/meshchatx/src/frontend/locales/es.json
+++ b/meshchatx/src/frontend/locales/es.json
@@ -370,6 +370,7 @@
"loading_overlay_title": "Perfil de conmutación",
"loading_overlay_subtitle": "Carga tus chats y configuraciones. Esto normalmente lleva unos segundos.",
"emergency_mode_active": "Modo de Emergencia Activo - Utilizar bases de datos en memoria y servicios restringidos.",
+ "demo_mode_active": "Modo demo: vitrina de malla de solo lectura. El envío y los cambios de configuración están deshabilitados.",
"backend_disconnected": "Desconectado desde el backend",
"backend_process_stopped": "Backend de Reticulum detenido",
"backend_reconnected": "Reconectado al backend",
@@ -3937,6 +3938,24 @@
"failed_load_identities": "Failed to load management identities",
"failed_create_identity": "Failed to create management identity"
},
+ "auth": {
+ "setup_title": "Configuración inicial",
+ "login_title": "Autenticación requerida",
+ "setup_subtitle": "Establezca una contraseña de administrador para proteger su instancia de MeshChatX",
+ "login_subtitle": "Introduzca su contraseña para continuar",
+ "password_label": "Contraseña",
+ "password_placeholder": "Introducir contraseña",
+ "password_min_length": "La contraseña debe tener al menos 8 caracteres",
+ "confirm_password_label": "Confirmar contraseña",
+ "confirm_password_placeholder": "Confirmar contraseña",
+ "processing": "Procesando...",
+ "set_password": "Establecer contraseña",
+ "login": "Iniciar sesión",
+ "passwords_mismatch": "Las contraseñas no coinciden",
+ "altcha_required": "Complete primero el desafío de verificación",
+ "status_check_failed": "No se pudo comprobar el estado de autenticación",
+ "failed": "Error de autenticación"
+ },
"post_install": {
"windows_screen_security_title": "¿Activar la seguridad de pantalla de Windows?",
"windows_screen_security_desc": "MeshChatX puede usar la protección de contenido de Windows (la misma marca tipo DRM que usan las apps de medios) para que capturas, grabadoras y Windows Recall no capturen la ventana. Puede cambiarlo después en Ajustes -> Privacidad.",

diff --git a/meshchatx/src/frontend/locales/fi.json b/meshchatx/src/frontend/locales/fi.json
index 134b26ab..1bdf8b7a 100644
--- a/meshchatx/src/frontend/locales/fi.json
+++ b/meshchatx/src/frontend/locales/fi.json
@@ -378,6 +378,7 @@
"loading_overlay_title": "Vaihdetaan profiilia",
"loading_overlay_subtitle": "Ladataan viestejä ja asetuksia. Tässä menee yleensä muutama sekunti.",
"emergency_mode_active": "Hätätila käynnissä - Käytetään muistinsisäistä tietokantaa ja rajoitettuja palveluja.",
+ "demo_mode_active": "Demotila: vain luku -verkkoesittely. Lähetys ja asetusten muutokset on poistettu käytöstä.",
"backend_disconnected": "Irrotettu taustapalvelusta",
"backend_process_stopped": "Reticulum-taustapalvelu pysäytetty",
"backend_reconnected": "Liitetty uudelleen taustapalveluun",
@@ -3937,6 +3938,24 @@
"failed_load_identities": "Failed to load management identities",
"failed_create_identity": "Failed to create management identity"
},
+ "auth": {
+ "setup_title": "Alkuasennus",
+ "login_title": "Tunnistautuminen vaaditaan",
+ "setup_subtitle": "Aseta ylläpitäjän salasana MeshChatX-instanssin suojaamiseksi",
+ "login_subtitle": "Anna salasana jatkaaksesi",
+ "password_label": "Salasana",
+ "password_placeholder": "Anna salasana",
+ "password_min_length": "Salasanan on oltava vähintään 8 merkkiä",
+ "confirm_password_label": "Vahvista salasana",
+ "confirm_password_placeholder": "Vahvista salasana",
+ "processing": "Käsitellään...",
+ "set_password": "Aseta salasana",
+ "login": "Kirjaudu",
+ "passwords_mismatch": "Salasanat eivät täsmää",
+ "altcha_required": "Suorita ensin vahvistustehtävä",
+ "status_check_failed": "Tunnistautumistilan tarkistus epäonnistui",
+ "failed": "Tunnistautuminen epäonnistui"
+ },
"post_install": {
"windows_screen_security_title": "Otetaanko Windowsin näytön suojaus käyttöön?",
"windows_screen_security_desc": "MeshChatX voi käyttää Windowsin sisällönsuojausta (samaa DRM-tyylistä näyttölippua kuin mediaohjelmat), jotta kuvakaappaukset, tallentimet ja Windows Recall eivät kaappaa sovellusikkunaa. Voit muuttaa tätä myöhemmin kohdassa Asetukset -> Tietosuoja.",

diff --git a/meshchatx/src/frontend/locales/fr.json b/meshchatx/src/frontend/locales/fr.json
index 75cea9ce..5a2512fb 100644
--- a/meshchatx/src/frontend/locales/fr.json
+++ b/meshchatx/src/frontend/locales/fr.json
@@ -370,6 +370,7 @@
"loading_overlay_title": "Profil de commutation",
"loading_overlay_subtitle": "Chargement de vos chats et paramètres. Cela prend généralement quelques secondes.",
"emergency_mode_active": "Mode d'urgence actif - Utilisation de la base de données en mémoire et des services restreints.",
+ "demo_mode_active": "Mode démo : vitrine mesh en lecture seule. L'envoi et les changements de paramètres sont désactivés.",
"backend_disconnected": "Déconnecté du moteur",
"backend_process_stopped": "Moteur Reticulum arrêté",
"backend_reconnected": "Reconnecté au moteur",
@@ -3937,6 +3938,24 @@
"failed_load_identities": "Failed to load management identities",
"failed_create_identity": "Failed to create management identity"
},
+ "auth": {
+ "setup_title": "Configuration initiale",
+ "login_title": "Authentification requise",
+ "setup_subtitle": "Définissez un mot de passe administrateur pour sécuriser votre instance MeshChatX",
+ "login_subtitle": "Veuillez saisir votre mot de passe pour continuer",
+ "password_label": "Mot de passe",
+ "password_placeholder": "Saisir le mot de passe",
+ "password_min_length": "Le mot de passe doit contenir au moins 8 caractères",
+ "confirm_password_label": "Confirmer le mot de passe",
+ "confirm_password_placeholder": "Confirmer le mot de passe",
+ "processing": "Traitement...",
+ "set_password": "Définir le mot de passe",
+ "login": "Connexion",
+ "passwords_mismatch": "Les mots de passe ne correspondent pas",
+ "altcha_required": "Terminez d'abord le défi de vérification",
+ "status_check_failed": "Impossible de vérifier l'état d'authentification",
+ "failed": "Échec de l'authentification"
+ },
"post_install": {
"windows_screen_security_title": "Activer la sécurité d’écran Windows ?",
"windows_screen_security_desc": "MeshChatX peut utiliser la protection de contenu Windows (le même indicateur d’affichage de type DRM que les apps multimédias) pour que les captures, enregistreurs et Windows Recall ne capturent pas la fenêtre. Modifiable plus tard dans Paramètres -> Confidentialité.",

diff --git a/meshchatx/src/frontend/locales/it.json b/meshchatx/src/frontend/locales/it.json
index 7a1170df..3624b0d6 100644
--- a/meshchatx/src/frontend/locales/it.json
+++ b/meshchatx/src/frontend/locales/it.json
@@ -370,6 +370,7 @@
"loading_overlay_title": "Cambio profilo",
"loading_overlay_subtitle": "Caricamento chat e impostazioni. Di solito bastano pochi secondi.",
"emergency_mode_active": "Modalità Emergenza Attiva - Utilizzo del database in memoria e servizi limitati.",
+ "demo_mode_active": "Modalità demo: vetrina mesh in sola lettura. Invio e modifiche alle impostazioni disabilitati.",
"backend_disconnected": "Disconnesso dal backend",
"backend_process_stopped": "Backend Reticulum arrestato",
"backend_reconnected": "Riconnesso al backend",
@@ -3937,6 +3938,24 @@
"failed_load_identities": "Failed to load management identities",
"failed_create_identity": "Failed to create management identity"
},
+ "auth": {
+ "setup_title": "Configurazione iniziale",
+ "login_title": "Autenticazione richiesta",
+ "setup_subtitle": "Imposta una password amministratore per proteggere la tua istanza MeshChatX",
+ "login_subtitle": "Inserisci la password per continuare",
+ "password_label": "Password",
+ "password_placeholder": "Inserisci password",
+ "password_min_length": "La password deve contenere almeno 8 caratteri",
+ "confirm_password_label": "Conferma password",
+ "confirm_password_placeholder": "Conferma password",
+ "processing": "Elaborazione...",
+ "set_password": "Imposta password",
+ "login": "Accedi",
+ "passwords_mismatch": "Le password non corrispondono",
+ "altcha_required": "Completa prima la verifica",
+ "status_check_failed": "Impossibile verificare lo stato di autenticazione",
+ "failed": "Autenticazione non riuscita"
+ },
"post_install": {
"windows_screen_security_title": "Attivare la sicurezza schermo di Windows?",
"windows_screen_security_desc": "MeshChatX può usare la protezione contenuti di Windows (lo stesso flag display in stile DRM delle app multimediali) così screenshot, registratori e Windows Recall non catturano la finestra. Modificabile in seguito in Impostazioni -> Privacy.",

diff --git a/meshchatx/src/frontend/locales/nl.json b/meshchatx/src/frontend/locales/nl.json
index f7c7e2f2..e8173030 100644
--- a/meshchatx/src/frontend/locales/nl.json
+++ b/meshchatx/src/frontend/locales/nl.json
@@ -370,6 +370,7 @@
"loading_overlay_title": "Schakelen van profiel",
"loading_overlay_subtitle": "Uw chats en instellingen laden. Dit duurt meestal een paar seconden.",
"emergency_mode_active": "Noodmodus actief - Gebruik van in-geheugen database en beperkte diensten.",
+ "demo_mode_active": "Demomodus: alleen-lezen mesh-demo. Verzenden en instellingen wijzigen is uitgeschakeld.",
"backend_disconnected": "Verbinding verbroken met backend",
"backend_process_stopped": "Reticulum-backend gestopt",
"backend_reconnected": "Verbinding met backend",
@@ -3937,6 +3938,24 @@
"failed_load_identities": "Failed to load management identities",
"failed_create_identity": "Failed to create management identity"
},
+ "auth": {
+ "setup_title": "Eerste installatie",
+ "login_title": "Authenticatie vereist",
+ "setup_subtitle": "Stel een beheerderswachtwoord in om uw MeshChatX-exemplaar te beveiligen",
+ "login_subtitle": "Voer uw wachtwoord in om door te gaan",
+ "password_label": "Wachtwoord",
+ "password_placeholder": "Wachtwoord invoeren",
+ "password_min_length": "Wachtwoord moet minstens 8 tekens lang zijn",
+ "confirm_password_label": "Wachtwoord bevestigen",
+ "confirm_password_placeholder": "Wachtwoord bevestigen",
+ "processing": "Bezig...",
+ "set_password": "Wachtwoord instellen",
+ "login": "Inloggen",
+ "passwords_mismatch": "Wachtwoorden komen niet overeen",
+ "altcha_required": "Voltooi eerst de verificatie-uitdaging",
+ "status_check_failed": "Authenticatiestatus kon niet worden gecontroleerd",
+ "failed": "Authenticatie mislukt"
+ },
"post_install": {
"windows_screen_security_title": "Windows-schermbeveiliging inschakelen?",
"windows_screen_security_desc": "MeshChatX kan Windows-inhoudsbescherming gebruiken (dezelfde DRM-achtige displayvlag als media-apps) zodat screenshots, recorders en Windows Recall het appvenster niet vastleggen. Later te wijzigen onder Instellingen -> Privacy.",

diff --git a/meshchatx/src/frontend/locales/ru.json b/meshchatx/src/frontend/locales/ru.json
index 86be9428..30cc28e5 100644
--- a/meshchatx/src/frontend/locales/ru.json
+++ b/meshchatx/src/frontend/locales/ru.json
@@ -343,6 +343,7 @@
"loading_overlay_title": "Смена профиля",
"loading_overlay_subtitle": "Загрузка чатов и настроек. Обычно это занимает несколько секунд.",
"emergency_mode_active": "Аварийный режим активен. Используется база данных в оперативной памяти и ограниченные службы.",
+ "demo_mode_active": "Демо-режим: демонстрация сети только для чтения. Отправка и изменение настроек отключены.",
"backend_disconnected": "Нет соединения с сервером",
"backend_process_stopped": "Сервер Reticulum остановлен",
"backend_reconnected": "Соединение с сервером восстановлено",
@@ -3937,6 +3938,24 @@
"failed_load_identities": "Failed to load management identities",
"failed_create_identity": "Failed to create management identity"
},
+ "auth": {
+ "setup_title": "Первоначальная настройка",
+ "login_title": "Требуется аутентификация",
+ "setup_subtitle": "Задайте пароль администратора для защиты экземпляра MeshChatX",
+ "login_subtitle": "Введите пароль, чтобы продолжить",
+ "password_label": "Пароль",
+ "password_placeholder": "Введите пароль",
+ "password_min_length": "Пароль должен содержать не менее 8 символов",
+ "confirm_password_label": "Подтвердите пароль",
+ "confirm_password_placeholder": "Подтвердите пароль",
+ "processing": "Обработка...",
+ "set_password": "Задать пароль",
+ "login": "Войти",
+ "passwords_mismatch": "Пароли не совпадают",
+ "altcha_required": "Сначала завершите проверку",
+ "status_check_failed": "Не удалось проверить состояние аутентификации",
+ "failed": "Ошибка аутентификации"
+ },
"post_install": {
"windows_screen_security_title": "Включить защиту экрана Windows?",
"windows_screen_security_desc": "MeshChatX может использовать защиту содержимого Windows (тот же DRM-подобный флаг отображения, что у медиаприложений), чтобы снимки, запись и Windows Recall не захватывали окно. Позже можно изменить в Настройки -> Конфиденциальность.",

diff --git a/meshchatx/src/frontend/locales/zh.json b/meshchatx/src/frontend/locales/zh.json
index 80bc84e1..ae94eb3a 100644
--- a/meshchatx/src/frontend/locales/zh.json
+++ b/meshchatx/src/frontend/locales/zh.json
@@ -370,6 +370,7 @@
"loading_overlay_title": "切换配置",
"loading_overlay_subtitle": "正在加载您的聊天和设置。这通常需要几秒钟。",
"emergency_mode_active": "紧急模式激活 - 使用内存数据库和受限服务。",
+ "demo_mode_active": "演示模式:只读网状网络展示。已禁用发送和设置更改。",
"backend_disconnected": "后端已断开",
"backend_process_stopped": "Reticulum 后端已停止",
"backend_reconnected": "已重新连接到后端",
@@ -3937,6 +3938,24 @@
"failed_load_identities": "Failed to load management identities",
"failed_create_identity": "Failed to create management identity"
},
+ "auth": {
+ "setup_title": "初始设置",
+ "login_title": "需要身份验证",
+ "setup_subtitle": "设置管理员密码以保护您的 MeshChatX 实例",
+ "login_subtitle": "请输入密码以继续",
+ "password_label": "密码",
+ "password_placeholder": "输入密码",
+ "password_min_length": "密码长度至少为 8 个字符",
+ "confirm_password_label": "确认密码",
+ "confirm_password_placeholder": "确认密码",
+ "processing": "处理中...",
+ "set_password": "设置密码",
+ "login": "登录",
+ "passwords_mismatch": "两次输入的密码不一致",
+ "altcha_required": "请先完成验证挑战",
+ "status_check_failed": "无法检查身份验证状态",
+ "failed": "身份验证失败"
+ },
"post_install": {
"windows_screen_security_title": "启用 Windows 屏幕安全?",
"windows_screen_security_desc": "MeshChatX 可使用 Windows 内容保护(与媒体应用相同的 DRM 风格显示标志),使截图、录屏和 Windows Recall 无法捕获应用窗口。之后可在 设置 -> 隐私 中更改。",

diff --git a/meshchatx/src/frontend/main.js b/meshchatx/src/frontend/main.js
index 129bc8ca..64ed62fc 100644
--- a/meshchatx/src/frontend/main.js
+++ b/meshchatx/src/frontend/main.js
@@ -384,6 +384,12 @@ if (networkReady) {
GlobalState.networkStarting = false;
GlobalState.networkReady = true;
}
+ try {
+ const statusResponse = await window.api.get("/api/v1/status");
+ GlobalState.demoMode = !!statusResponse.data?.demo_mode;
+ } catch {
+ // status optional during early boot
+ }
try {
await fetchCsrfToken(window.api);
} catch {
@@ -396,6 +402,7 @@ if (networkReady) {
const status = response.data;
GlobalState.authEnabled = !!status.auth_enabled;
GlobalState.authenticated = !!status.authenticated;
+ GlobalState.demoMode = !!status.demo_mode;
GlobalState.authSessionResolved = true;
if (!status.auth_enabled) {

diff --git a/package.json b/package.json
index cdb52977..a7df7e5a 100644
--- a/package.json
+++ b/package.json
@@ -292,6 +292,7 @@
"@mdi/js": "^7.4.47",
"@tailwindcss/forms": "^0.5.11",
"@tanstack/vue-virtual": "^3.13.34",
+ "altcha": "3.2.1",
"compressorjs": "^1.3.0",
"dompurify": "^3.4.12",
"electron-prompt": "^1.7.0",

diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 6f8c312d..f22295c8 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -74,6 +74,9 @@ importers:
'@tanstack/vue-virtual':
specifier: ^3.13.34
version: 3.13.34(vue@3.5.40(typescript@6.0.3))
+ altcha:
+ specifier: 3.2.1
+ version: 3.2.1
compressorjs:
specifier: ^1.3.0
version: 1.3.0
@@ -1284,6 +1287,9 @@ packages:
alien-signals@3.2.1:
resolution: {integrity: sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==}
+ altcha@3.2.1:
+ resolution: {integrity: sha512-MXsnNmgg3BGJja0DXbflv9Bp29uyGv6wUImaaT1yLxCKOj+x9QBMpi/Vdh6XbP4Uf0PykaeQwl8ivJAvIhoo0A==}
+
amdefine@1.0.1:
resolution: {integrity: sha512-S2Hw0TtNkMJhIabBwIojKL9YHO5T0n5eNqWJ7Lrlel/zDbftQpxpapi8tZs3X1HWa+u+QeydGmzzNU0m09+Rcg==}
engines: {node: '>=0.4.2'}
@@ -2131,6 +2137,9 @@ packages:
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
engines: {node: '>= 0.4'}
+ hash-wasm@4.12.0:
+ resolution: {integrity: sha512-+/2B2rYLb48I/evdOIhP+K/DD2ca2fgBjp6O+GBEnCDk2e4rpeXIK8GvIyRPjTezgmWn9gmKwkQjjx6BtqDHVQ==}
+
hasown@2.0.4:
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
engines: {node: '>= 0.4'}
@@ -4124,7 +4133,7 @@ snapshots:
'@isaacs/cliui@8.0.2':
dependencies:
string-width: 5.1.2
- string-width-cjs: string-width@4.2.3
+ string-width-cjs: string-width@4.2.0
strip-ansi: 6.0.1
strip-ansi-cjs: strip-ansi@6.0.1
wrap-ansi: 8.1.0
@@ -4829,6 +4838,10 @@ snapshots:
alien-signals@3.2.1: {}
+ altcha@3.2.1:
+ dependencies:
+ hash-wasm: 4.12.0
+
amdefine@1.0.1: {}
ansi-regex@5.0.1: {}
@@ -5768,6 +5781,8 @@ snapshots:
dependencies:
has-symbols: 1.0.3
+ hash-wasm@4.12.0: {}
+
hasown@2.0.4:
dependencies:
function-bind: 1.1.2

diff --git a/scripts/ci/docker-demo-smoke.sh b/scripts/ci/docker-demo-smoke.sh
new file mode 100755
index 00000000..f3f6a8a5
--- /dev/null
+++ b/scripts/ci/docker-demo-smoke.sh
@@ -0,0 +1,61 @@
+#!/usr/bin/env bash
+# Smoke-test docker-compose.demo.yml (Coolify-shaped demo stack).
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
+cd "$ROOT"
+
+IMAGE="${MESHCHAT_DOCKER_DEMO_IMAGE:-reticulum-meshchatx:local}"
+COMPOSE_FILE="docker-compose.demo.yml"
+OVERRIDE_FILE="${TMPDIR:-/tmp}/meshchatx-demo-smoke-ports.yml"
+KEY="${MESHCHAT_ALTCHA_HMAC_KEY:-demo-smoke-hmac-key-change-me}"
+
+cleanup() {
+ MESHCHAT_IMAGE="$IMAGE" MESHCHAT_ALTCHA_HMAC_KEY="$KEY" \
+ docker compose -f "$COMPOSE_FILE" -f "$OVERRIDE_FILE" down >/dev/null 2>&1 || true
+ rm -f "$OVERRIDE_FILE"
+}
+trap cleanup EXIT
+
+cat >"$OVERRIDE_FILE" <<'EOF'
+services:
+ meshchatx:
+ ports:
+ - 127.0.0.1:8000:8000
+EOF
+
+if ! docker image inspect "$IMAGE" >/dev/null 2>&1; then
+ echo "Building $IMAGE..."
+ docker build -f Dockerfile -t "$IMAGE" .
+fi
+
+MESHCHAT_IMAGE="$IMAGE" MESHCHAT_ALTCHA_HMAC_KEY="$KEY" \
+ docker compose -f "$COMPOSE_FILE" -f "$OVERRIDE_FILE" up -d --pull never
+
+deadline=$((SECONDS + 180))
+while [ "$SECONDS" -lt "$deadline" ]; do
+ if curl -fsS "http://127.0.0.1:8000/api/v1/status" 2>/dev/null | python3 -c '
+import json,sys
+d=json.load(sys.stdin)
+sys.exit(0 if d.get("demo_mode") and d.get("altcha_enabled") else 1)
+'; then
+ break
+ fi
+ sleep 3
+done
+
+curl -fsS "http://127.0.0.1:8000/api/v1/status" | python3 -c '
+import json,sys
+d=json.load(sys.stdin)
+assert d.get("demo_mode") is True
+assert d.get("altcha_enabled") is True
+print("demo status ok", d.get("status"))
+'
+
+code=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
+ -H "Content-Type: application/json" \
+ -d "{}" \
+ "http://127.0.0.1:8000/api/v1/lxmf-messages/send")
+test "$code" = "403"
+
+echo "Docker demo smoke passed"

diff --git a/tests/backend/conftest.py b/tests/backend/conftest.py
index 3244c0bc..96140b19 100644
--- a/tests/backend/conftest.py
+++ b/tests/backend/conftest.py
@@ -291,6 +291,8 @@ def mock_app(db, tmp_path, temp_db):
if app.rrc_manager is not None:
app.rrc_manager.set_database(app.database)
app.websocket_broadcast = MagicMock(side_effect=lambda data: None)
+ app.demo_mode = False
+ app.altcha_enabled = False
yield app
app.teardown_identity()
@@ -306,8 +308,8 @@ async def fetch_api_csrf_headers(client):
def extend_meshchat_middlewares(aio_app, middlewares):
- auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw = middlewares
- aio_app.middlewares.extend([auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw])
+ auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw, demo_mw = middlewares
+ aio_app.middlewares.extend([auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw, demo_mw])
def pytest_collection_modifyitems(session, config, items):

diff --git a/tests/backend/demo_http_support.py b/tests/backend/demo_http_support.py
new file mode 100644
index 00000000..4274a64c
--- /dev/null
+++ b/tests/backend/demo_http_support.py
@@ -0,0 +1,25 @@
+# SPDX-License-Identifier: 0BSD
+
+from __future__ import annotations
+
+import secrets
+
+from aiohttp import web
+from aiohttp_session import setup as setup_session
+
+from tests.backend.conftest import extend_meshchat_middlewares
+
+
+def build_test_aio_app(app, *, https: bool = False):
+ app.session_secret_key = secrets.token_urlsafe(32)
+ app.listen_host = "127.0.0.1"
+ app.listen_port = 8000
+ app.use_https = https
+ app.landlock_active = False
+ routes = web.RouteTableDef()
+ middlewares = app._define_routes(routes)
+ aio_app = web.Application()
+ setup_session(aio_app, app._encrypted_cookie_storage(https))
+ extend_meshchat_middlewares(aio_app, middlewares)
+ aio_app.add_routes(routes)
+ return aio_app

diff --git a/tests/backend/test_access_attempts_enforcement.py b/tests/backend/test_access_attempts_enforcement.py
index 08cf25db..9e94856d 100644
--- a/tests/backend/test_access_attempts_enforcement.py
+++ b/tests/backend/test_access_attempts_enforcement.py
@@ -236,7 +236,7 @@ def test_enforce_untrusted_monotone_hypothesis(mock_app, n):
def _make_aio_app(mock_app, use_https: bool):
mock_app.session_secret_key = secrets.token_urlsafe(32)
routes = web.RouteTableDef()
- auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw = mock_app._define_routes(routes)
+ auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw, demo_mw = mock_app._define_routes(routes)
aio_app = web.Application()
setup_session(aio_app, mock_app._encrypted_cookie_storage(use_https))
aio_app.middlewares.extend([auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw])

diff --git a/tests/backend/test_altcha_oracle.py b/tests/backend/test_altcha_oracle.py
new file mode 100644
index 00000000..57da0519
--- /dev/null
+++ b/tests/backend/test_altcha_oracle.py
@@ -0,0 +1,104 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Oracle: ALTCHA challenge round-trip and login rejection without payload."""
+
+from __future__ import annotations
+
+import base64
+import json
+import os
+import time
+from unittest.mock import patch
+
+import altcha
+import bcrypt
+import pytest
+from aiohttp.test_utils import TestClient, TestServer
+
+from meshchatx.src.backend.altcha_auth import (
+ ALTCHA_INVALID_CODE,
+ verify_altcha_submission,
+)
+from tests.backend.demo_http_support import build_test_aio_app
+from tests.backend.conftest import fetch_api_csrf_headers
+
+_TEST_SECRET = "test-secret-key-32chars-minimum!!"
+
+
+def _solved_payload_b64(secret: str) -> str:
+ challenge = altcha.create_challenge(
+ "PBKDF2/SHA-256",
+ 200,
+ hmac_secret=secret,
+ expires_at=int(time.time()) + 300,
+ )
+ solution = altcha.solve_challenge(challenge, counter_step=1)
+ assert solution is not None
+ payload = altcha.Payload(challenge, solution)
+ raw = json.dumps(payload.to_dict())
+ return base64.b64encode(raw.encode()).decode()
+
+
+def test_altcha_verify_round_trip():
+ with patch.dict(
+ os.environ, {"MESHCHAT_ALTCHA_HMAC_KEY": _TEST_SECRET}, clear=False
+ ):
+ ok, code = verify_altcha_submission(_solved_payload_b64(_TEST_SECRET))
+ assert ok is True
+ assert code is None
+
+
+def test_altcha_rejects_tampered_payload():
+ with patch.dict(
+ os.environ, {"MESHCHAT_ALTCHA_HMAC_KEY": _TEST_SECRET}, clear=False
+ ):
+ bad = _solved_payload_b64(_TEST_SECRET)[:-4] + "XXXX"
+ ok, code = verify_altcha_submission(bad)
+ assert ok is False
+ assert code is not None
+
+
+@pytest.mark.asyncio
+async def test_login_without_altcha_when_enabled(mock_app):
+ mock_app.altcha_enabled = True
+ mock_app.demo_mode = False
+ mock_app.current_context.running = True
+ mock_app.config.auth_enabled.set(True)
+ password_hash = bcrypt.hashpw(b"secretpass", bcrypt.gensalt()).decode("utf-8")
+ mock_app.config.auth_password_hash.set(password_hash)
+
+ env = {
+ "MESHCHAT_ALTCHA_ENABLED": "1",
+ "MESHCHAT_ALTCHA_HMAC_KEY": _TEST_SECRET,
+ }
+ aio_app = build_test_aio_app(mock_app)
+ async with TestClient(TestServer(aio_app)) as client:
+ with patch.dict(os.environ, env, clear=False):
+ headers = await fetch_api_csrf_headers(client)
+ response = await client.post(
+ "/api/v1/auth/login",
+ json={"password": "secretpass"},
+ headers=headers,
+ )
+ assert response.status == 400
+ body = await response.json()
+ assert body.get("code") == ALTCHA_INVALID_CODE
+
+
+@pytest.mark.asyncio
+async def test_altcha_challenge_endpoint(mock_app):
+ mock_app.altcha_enabled = True
+ mock_app.current_context.running = True
+ env = {
+ "MESHCHAT_ALTCHA_ENABLED": "1",
+ "MESHCHAT_ALTCHA_HMAC_KEY": _TEST_SECRET,
+ }
+ aio_app = build_test_aio_app(mock_app)
+ async with TestClient(TestServer(aio_app)) as client:
+ with patch.dict(os.environ, env, clear=False):
+ response = await client.get("/api/v1/auth/altcha/challenge")
+ assert response.status == 200
+ data = await response.json()
+ assert "parameters" in data
+ assert "signature" in data
+ assert data["parameters"].get("algorithm") == "PBKDF2/SHA-256"

diff --git a/tests/backend/test_csp_logic.py b/tests/backend/test_csp_logic.py
index c4be6e64..076c65cb 100644
--- a/tests/backend/test_csp_logic.py
+++ b/tests/backend/test_csp_logic.py
@@ -59,7 +59,7 @@ async def test_csp_header_logic(mock_rns_minimal, tmp_path):
# Call _define_routes to get the security_middleware
routes = web.RouteTableDef()
- _, _, security_middleware, _, _ = app_instance._define_routes(routes)
+ _, _, security_middleware, _, _, _ = app_instance._define_routes(routes)
response = await security_middleware(request, mock_handler)
@@ -102,7 +102,7 @@ async def test_security_middleware_sets_cors_headers_on_rnode_flasher(
return web.Response(text="// module")
routes = web.RouteTableDef()
- _, _, security_middleware, _, _ = app_instance._define_routes(routes)
+ _, _, security_middleware, _, _, _ = app_instance._define_routes(routes)
response = await security_middleware(request, mock_handler)
@@ -136,7 +136,7 @@ async def test_security_middleware_does_not_set_cors_on_reticulum_docs(
return web.Response(text="<html></html>")
routes = web.RouteTableDef()
- _, _, security_middleware, _, _ = app_instance._define_routes(routes)
+ _, _, security_middleware, _, _, _ = app_instance._define_routes(routes)
response = await security_middleware(request, mock_handler)
@@ -223,7 +223,7 @@ async def test_csp_privacy_mode_strips_external_sources(mock_rns_minimal, tmp_pa
return web.Response(text="test")
routes = web.RouteTableDef()
- _, _, security_middleware, _, _ = app_instance._define_routes(routes)
+ _, _, security_middleware, _, _, _ = app_instance._define_routes(routes)
response = await security_middleware(request, mock_handler)
csp = response.headers.get("Content-Security-Policy", "")
assert "openstreetmap.org" not in csp
@@ -247,7 +247,7 @@ async def _csp_for_path(app_instance, path: str) -> str:
return web.Response(text="test")
routes = web.RouteTableDef()
- _, _, security_middleware, _, _ = app_instance._define_routes(routes)
+ _, _, security_middleware, _, _, _, _ = app_instance._define_routes(routes)
response = await security_middleware(request, mock_handler)
return response.headers.get("Content-Security-Policy", "")

diff --git a/tests/backend/test_deferred_network_startup.py b/tests/backend/test_deferred_network_startup.py
index ff80d9e2..dbcf4e30 100644
--- a/tests/backend/test_deferred_network_startup.py
+++ b/tests/backend/test_deferred_network_startup.py
@@ -336,8 +336,10 @@ async def test_auth_middleware_allows_status_and_static_while_starting(
):
app = _make_deferred_app(mock_identity, temp_dir)
routes = web.RouteTableDef()
- auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw = app._define_routes(routes)
- aio_app = web.Application(middlewares=[auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw])
+ auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw, demo_mw = app._define_routes(routes)
+ aio_app = web.Application(
+ middlewares=[auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw, demo_mw]
+ )
aio_app.add_routes(routes)
async with TestClient(TestServer(aio_app)) as client:
@@ -362,8 +364,10 @@ async def test_auth_middleware_allows_status_and_static_while_starting(
async def test_auth_middleware_allows_csrf_while_starting(mock_identity, temp_dir):
app = _make_deferred_app(mock_identity, temp_dir)
routes = web.RouteTableDef()
- auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw = app._define_routes(routes)
- aio_app = web.Application(middlewares=[auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw])
+ auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw, demo_mw = app._define_routes(routes)
+ aio_app = web.Application(
+ middlewares=[auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw, demo_mw]
+ )
aio_app.add_routes(routes)
with patch(

diff --git a/tests/backend/test_demo_auth_oracle.py b/tests/backend/test_demo_auth_oracle.py
new file mode 100644
index 00000000..e694a40e
--- /dev/null
+++ b/tests/backend/test_demo_auth_oracle.py
@@ -0,0 +1,28 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Oracle: auth bypass env and demo password seeding."""
+
+from __future__ import annotations
+
+import os
+from unittest.mock import patch
+
+import bcrypt
+
+
+def test_auth_bypass_disables_auth_enabled(mock_app):
+ with patch.dict(os.environ, {"MESHCHAT_AUTH_BYPASS": "1"}, clear=False):
+ mock_app.auth_enabled_initial = True
+ mock_app.config.auth_enabled.set(True)
+ assert mock_app.auth_enabled is False
+
+
+def test_demo_mode_seeds_password_hash(mock_app):
+ mock_app.demo_mode = True
+ mock_app.auth_enabled_initial = True
+ mock_app.config.auth_password_hash.set(None)
+ with patch.dict(os.environ, {"MESHCHAT_DEMO_AUTH_PASSWORD": "demo"}, clear=False):
+ mock_app._apply_demo_mode_runtime()
+ stored = mock_app.config.auth_password_hash.get()
+ assert stored is not None
+ assert bcrypt.checkpw(b"demo", stored.encode("utf-8"))

diff --git a/tests/backend/test_demo_mode_http_oracle.py b/tests/backend/test_demo_mode_http_oracle.py
new file mode 100644
index 00000000..262c1cf8
--- /dev/null
+++ b/tests/backend/test_demo_mode_http_oracle.py
@@ -0,0 +1,70 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Oracle: demo mode blocks HTTP mutations outside the allowlist."""
+
+from __future__ import annotations
+
+import pytest
+from aiohttp.test_utils import TestClient, TestServer
+from hypothesis import given, settings
+from hypothesis import strategies as st
+
+from meshchatx.src.backend.demo_mode import (
+ DEMO_HTTP_MUTATION_ALLOWLIST,
+ DEMO_READONLY_CODE,
+ demo_http_mutation_allowed,
+)
+from tests.backend.demo_http_support import build_test_aio_app
+
+
+@pytest.mark.parametrize(
+ ("method", "path"),
+ [
+ ("POST", "/api/v1/lxmf-messages/send"),
+ ("POST", "/api/v1/identities/create"),
+ ("DELETE", "/api/v1/lxmf-messages/aa"),
+ ("PATCH", "/api/v1/server/security"),
+ ],
+)
+@pytest.mark.asyncio
+async def test_demo_mode_blocks_disallowed_mutations(mock_app, method, path):
+ mock_app.demo_mode = True
+ mock_app.current_context.running = True
+ mock_app.config.auth_enabled.set(False)
+ aio_app = build_test_aio_app(mock_app)
+ async with TestClient(TestServer(aio_app)) as client:
+ response = await client.request(method, path, json={})
+ assert response.status == 403
+ body = await response.json()
+ assert body.get("code") == DEMO_READONLY_CODE
+
+
+@pytest.mark.asyncio
+async def test_demo_mode_status_includes_flags(mock_app):
+ mock_app.demo_mode = True
+ mock_app.altcha_enabled = True
+ mock_app.current_context.running = True
+ mock_app._network_ready = True
+ payload = mock_app._startup_status_payload()
+ assert payload["demo_mode"] is True
+ assert payload["altcha_enabled"] is True
+
+
+@given(
+ suffix=st.text(
+ alphabet=st.characters(blacklist_categories=("Cs",)),
+ min_size=0,
+ max_size=24,
+ ),
+)
+@settings(max_examples=40, deadline=None)
+def test_demo_http_mutation_fuzz_rejects_unknown_paths(suffix):
+ path = f"/api/v1/lxmf-messages/send{suffix}"
+ if path in DEMO_HTTP_MUTATION_ALLOWLIST:
+ return
+ assert demo_http_mutation_allowed("POST", path) is False
+
+
+def test_allowlist_is_explicit_set():
+ assert "/api/v1/auth/login" in DEMO_HTTP_MUTATION_ALLOWLIST
+ assert "/api/v1/lxmf-messages/send" not in DEMO_HTTP_MUTATION_ALLOWLIST

diff --git a/tests/backend/test_demo_mode_mesh.py b/tests/backend/test_demo_mode_mesh.py
new file mode 100644
index 00000000..2b3956fb
--- /dev/null
+++ b/tests/backend/test_demo_mode_mesh.py
@@ -0,0 +1,19 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Demo mode skips outbound announces."""
+
+from __future__ import annotations
+
+import pytest
+from unittest.mock import MagicMock
+
+
+@pytest.mark.asyncio
+async def test_announce_noop_in_demo_mode(mock_app):
+ mock_app.demo_mode = True
+ ctx = mock_app.current_context
+ ctx.message_router = MagicMock()
+ ctx.telephone_manager = MagicMock()
+ await mock_app.announce(context=ctx)
+ ctx.message_router.announce.assert_not_called()
+ ctx.telephone_manager.announce.assert_not_called()

diff --git a/tests/backend/test_demo_mode_websocket_oracle.py b/tests/backend/test_demo_mode_websocket_oracle.py
new file mode 100644
index 00000000..d81935dd
--- /dev/null
+++ b/tests/backend/test_demo_mode_websocket_oracle.py
@@ -0,0 +1,17 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Oracle: WebSocket mutators rejected in demo mode."""
+
+from __future__ import annotations
+
+import pytest
+
+from meshchatx.src.backend.demo_mode import demo_mode_active
+from meshchatx.src.backend.websocket_config_guard import WEBSOCKET_MUTATOR_TYPES
+
+
+@pytest.mark.parametrize("msg_type", sorted(WEBSOCKET_MUTATOR_TYPES))
+def test_demo_mode_blocks_ws_mutator_types(mock_app, msg_type):
+ mock_app.demo_mode = True
+ assert demo_mode_active(mock_app) is True
+ assert msg_type in WEBSOCKET_MUTATOR_TYPES

diff --git a/tests/backend/test_docker_runtime_smoke.py b/tests/backend/test_docker_runtime_smoke.py
index 190cb122..aee6eed4 100644
--- a/tests/backend/test_docker_runtime_smoke.py
+++ b/tests/backend/test_docker_runtime_smoke.py
@@ -14,6 +14,7 @@ import pytest
_REPO_ROOT = Path(__file__).resolve().parents[2]
_SMOKE_SCRIPT = _REPO_ROOT / "scripts" / "ci" / "docker-runtime-smoke.sh"
_HARDENED_SMOKE_SCRIPT = _REPO_ROOT / "scripts" / "ci" / "docker-hardened-smoke.sh"
+_DEMO_SMOKE_SCRIPT = _REPO_ROOT / "scripts" / "ci" / "docker-demo-smoke.sh"
def _docker_available() -> bool:
@@ -82,6 +83,30 @@ def test_docker_hardened_compose_and_run_serve_status():
assert '"status": "ok"' in result.stdout or '"status":"ok"' in result.stdout
+@pytest.mark.integration
+@pytest.mark.skipif(
+ os.environ.get("MESHCHAT_DOCKER_SMOKE") != "1",
+ reason="Set MESHCHAT_DOCKER_SMOKE=1 to run Docker demo compose smoke",
+)
+@pytest.mark.skipif(not _docker_available(), reason="Docker is not available")
+def test_docker_demo_compose_smoke():
+ env = os.environ.copy()
+ env.setdefault("MESHCHAT_DOCKER_SMOKE_TIMEOUT", "240")
+ result = subprocess.run(
+ ["bash", str(_DEMO_SMOKE_SCRIPT)],
+ cwd=_REPO_ROOT,
+ env=env,
+ capture_output=True,
+ text=True,
+ timeout=int(env["MESHCHAT_DOCKER_SMOKE_TIMEOUT"]) + 600,
+ check=False,
+ )
+ assert result.returncode == 0, (
+ f"Docker demo smoke failed (exit {result.returncode})\n"
+ f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
+ )
+
+
@pytest.mark.integration
@pytest.mark.skipif(
os.environ.get("MESHCHAT_DOCKER_SMOKE") != "1",

diff --git a/tests/backend/test_identity_restore_http_api.py b/tests/backend/test_identity_restore_http_api.py
index 3b5eaf84..059eac23 100644
--- a/tests/backend/test_identity_restore_http_api.py
+++ b/tests/backend/test_identity_restore_http_api.py
@@ -15,8 +15,10 @@ pytestmark = pytest.mark.usefixtures("require_loopback_tcp")
def _build_aio_app(app):
routes = web.RouteTableDef()
- auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw = app._define_routes(routes)
- aio_app = web.Application(middlewares=[auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw])
+ auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw, demo_mw = app._define_routes(routes)
+ aio_app = web.Application(
+ middlewares=[auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw, demo_mw]
+ )
aio_app.add_routes(routes)
return aio_app

diff --git a/tests/backend/test_identity_switch_http_api.py b/tests/backend/test_identity_switch_http_api.py
index 2cd4c639..e28792ae 100644
--- a/tests/backend/test_identity_switch_http_api.py
+++ b/tests/backend/test_identity_switch_http_api.py
@@ -16,8 +16,10 @@ pytestmark = pytest.mark.usefixtures("require_loopback_tcp")
def _build_aio_app(app):
routes = web.RouteTableDef()
- auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw = app._define_routes(routes)
- aio_app = web.Application(middlewares=[auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw])
+ auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw, demo_mw = app._define_routes(routes)
+ aio_app = web.Application(
+ middlewares=[auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw, demo_mw]
+ )
aio_app.add_routes(routes)
return aio_app

diff --git a/tests/backend/test_lxmf_cancel_api.py b/tests/backend/test_lxmf_cancel_api.py
index 280accff..7347a538 100644
--- a/tests/backend/test_lxmf_cancel_api.py
+++ b/tests/backend/test_lxmf_cancel_api.py
@@ -13,8 +13,10 @@ from aiohttp.test_utils import TestClient, TestServer
def _build_aio_app(app):
routes = web.RouteTableDef()
- auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw = app._define_routes(routes)
- aio_app = web.Application(middlewares=[auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw])
+ auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw, demo_mw = app._define_routes(routes)
+ aio_app = web.Application(
+ middlewares=[auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw, demo_mw],
+ )
aio_app.add_routes(routes)
return aio_app

diff --git a/tests/backend/test_media_http_api.py b/tests/backend/test_media_http_api.py
index 1bbed6ab..be8166de 100644
--- a/tests/backend/test_media_http_api.py
+++ b/tests/backend/test_media_http_api.py
@@ -17,8 +17,10 @@ pytestmark = pytest.mark.usefixtures("require_loopback_tcp")
def _build_aio_app(app):
routes = web.RouteTableDef()
- auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw = app._define_routes(routes)
- aio_app = web.Application(middlewares=[auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw])
+ auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw, demo_mw = app._define_routes(routes)
+ aio_app = web.Application(
+ middlewares=[auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw, demo_mw]
+ )
aio_app.add_routes(routes)
return aio_app

diff --git a/tests/backend/test_notification_user_facing_filter.py b/tests/backend/test_notification_user_facing_filter.py
index 6d94d691..837a1150 100644
--- a/tests/backend/test_notification_user_facing_filter.py
+++ b/tests/backend/test_notification_user_facing_filter.py
@@ -442,8 +442,10 @@ pytestmark_integration = pytest.mark.usefixtures("require_loopback_tcp")
def _build_aio_app(app):
routes = web.RouteTableDef()
- auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw = app._define_routes(routes)
- aio_app = web.Application(middlewares=[auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw])
+ auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw, demo_mw = app._define_routes(routes)
+ aio_app = web.Application(
+ middlewares=[auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw, demo_mw]
+ )
aio_app.add_routes(routes)
return aio_app

diff --git a/tests/backend/test_notifications.py b/tests/backend/test_notifications.py
index 566a4b5e..5d0a2f1c 100644
--- a/tests/backend/test_notifications.py
+++ b/tests/backend/test_notifications.py
@@ -466,8 +466,10 @@ async def test_auth_middleware_returns_401_for_api_without_session_when_auth_ena
app.config.auth_enabled.set(True)
routes = web.RouteTableDef()
- auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw = app._define_routes(routes)
- aio_app = web.Application(middlewares=[auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw])
+ auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw, demo_mw = app._define_routes(routes)
+ aio_app = web.Application(
+ middlewares=[auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw, demo_mw]
+ )
aio_app.add_routes(routes)
async with TestClient(TestServer(aio_app)) as client:

diff --git a/tests/backend/test_rnode_download_firmware.py b/tests/backend/test_rnode_download_firmware.py
index 69ba507b..5250f623 100644
--- a/tests/backend/test_rnode_download_firmware.py
+++ b/tests/backend/test_rnode_download_firmware.py
@@ -16,8 +16,10 @@ pytestmark = pytest.mark.usefixtures("require_loopback_tcp")
def _build_aio_app(app):
routes = web.RouteTableDef()
- auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw = app._define_routes(routes)
- aio_app = web.Application(middlewares=[auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw])
+ auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw, demo_mw = app._define_routes(routes)
+ aio_app = web.Application(
+ middlewares=[auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw, demo_mw]
+ )
aio_app.add_routes(routes)
return aio_app

diff --git a/tests/backend/test_telephone_audio_ws.py b/tests/backend/test_telephone_audio_ws.py
index 594d1249..83f4a28d 100644
--- a/tests/backend/test_telephone_audio_ws.py
+++ b/tests/backend/test_telephone_audio_ws.py
@@ -12,8 +12,10 @@ pytestmark = pytest.mark.usefixtures("require_loopback_tcp")
def _build_aio_app(app):
routes = web.RouteTableDef()
- auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw = app._define_routes(routes)
- aio_app = web.Application(middlewares=[auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw])
+ auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw, demo_mw = app._define_routes(routes)
+ aio_app = web.Application(
+ middlewares=[auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw, demo_mw]
+ )
aio_app.add_routes(routes)
return aio_app

diff --git a/tests/frontend/AuthPage.test.js b/tests/frontend/AuthPage.test.js
index 1e6bb627..2d7f3b5f 100644
--- a/tests/frontend/AuthPage.test.js
+++ b/tests/frontend/AuthPage.test.js
@@ -2,6 +2,26 @@ import { mount } from "@vue/test-utils";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import AuthPage from "../../meshchatx/src/frontend/components/auth/AuthPage.vue";
+const authI18n = {
+ "auth.setup_title": "Initial Setup",
+ "auth.login_title": "Authentication Required",
+ "auth.setup_subtitle": "Set an admin password to secure your MeshChatX instance",
+ "auth.login_subtitle": "Please enter your password to continue",
+ "auth.password_label": "Password",
+ "auth.password_placeholder": "Enter password",
+ "auth.password_min_length": "Password must be at least 8 characters long",
+ "auth.confirm_password_label": "Confirm Password",
+ "auth.confirm_password_placeholder": "Confirm password",
+ "auth.processing": "Processing...",
+ "auth.set_password": "Set Password",
+ "auth.login": "Login",
+ "auth.passwords_mismatch": "Passwords do not match",
+ "auth.altcha_required": "Complete the verification challenge first",
+ "auth.status_check_failed": "Failed to check authentication status",
+ "auth.failed": "Authentication failed",
+ "app.demo_mode_active": "Demo mode active",
+};
+
describe("AuthPage.vue", () => {
let axiosMock;
let routerMock;
@@ -41,6 +61,12 @@ describe("AuthPage.vue", () => {
global: {
mocks: {
$router: routerMock,
+ $t: (key) => authI18n[key] || key,
+ },
+ config: {
+ compilerOptions: {
+ isCustomElement: (tag) => tag === "altcha-widget",
+ },
},
},
});


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────